Transaction - JPA, JDBC, DB

#Spring #Spring_JDBC #Spring_JPA #Spring_DB

이전 포스트:
JDBC와 JPA
JPA-EntityManager, PersistenceContext

1. Transaction은 모두에게 속해있습니다.

트랜잭션은 '데이터 관련 처리 작업'을 구분하는 '단위' 이다.
데이터베이스에는 애초에 트랜잭션을 위한 기능들이 구비되어 있을것이고, JDBC도 데이터베이스와 애플리케이션 사이의 물리적 연결을 제어하는 부분이기 때문에 트랜잭션 개념이 함께 올라왔을 것이고, JPA는 엔티티의 라이프사이클을 관리하기 때문에서라도 트랜잭션 개념이 필요할 것이다.

하지만, 위에서 느낄 수 있듯이 물리적인 부분에서의 필요성과 엔티티 생명주기라는 논리적 관점에서의 필요성의 차이로 트랜잭션의 형태와 작용은 사뭇 다르다. (JPA도 결국엔 물리적 트랜잭션까지 이어지겠지만, 딱 JPA 계층만 보도록 한다.)


2. JDBC의 Connection은 Transaction이 새로 생성될 때마다 획득될까요? (Connection Pool을 사용할 때)

=> 새로운 Connection이 연결된다.

이것을 테스트할 때 주로 2가지 방법을 사용해서 설명하는 것 같다.

2-1. DataSourceTransactionManager

가장 쉽게 확인하는 방법은 DataSourceTransactionManager를 사용해서 확인하는 방법이다. 이것은 로그레벨로 어떤 Connection을 사용하고 있는지를 보여주기 때문이다.

import lombok.extern.slf4j.Slf4j;  
import org.junit.jupiter.api.Test;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.test.context.SpringBootTest;  
import org.springframework.jdbc.datasource.DataSourceTransactionManager;  
import org.springframework.transaction.TransactionDefinition;  
import org.springframework.transaction.TransactionStatus;  
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;  
  
@SpringBootTest(classes = TransactionConfig.class)  
@Slf4j  
public class DataSourceTransactionTest {  
    @Autowired  
    DataSourceTransactionManager txManager;  
  
    @Test  
    void inner_rollback_required_new() {  
        log.info("외부 트랜잭션 시작");  
        TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());  
        log.info("outer.isNewTransaction()={}", outer.isNewTransaction()); // true  
  
        log.info("내부 트랜잭션 시작");  
        DefaultTransactionAttribute definition = new DefaultTransactionAttribute();  
        definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);  
        TransactionStatus inner = txManager.getTransaction(definition);  
        log.info("outer.isNewTransaction()={}", inner.isNewTransaction()); // true  
  
        log.info("내부 트랜잭션 롤백");  
        txManager.rollback(inner); // 롤백  
  
        log.info("외부 트랜잭션 커밋");  
        txManager.commit(outer);  
    }}
import com.zaxxer.hikari.HikariDataSource;  
import org.springframework.boot.context.properties.ConfigurationProperties;  
import org.springframework.context.annotation.Bean;  
import org.springframework.jdbc.datasource.DataSourceTransactionManager;  
import org.springframework.transaction.PlatformTransactionManager;  
  
import javax.sql.DataSource;  
  
import static hello.jpaexample.connection.ConnectionConst.*;  
  

public class TransactionConfig {  
    @Bean  
    @ConfigurationProperties(prefix = "spring.datasource")  
    public DataSource dataSource() {  
        HikariDataSource dataSource = new HikariDataSource();  
        dataSource.setJdbcUrl(URL);  
        dataSource.setUsername(USERNAME);  
        dataSource.setPassword(PASSWORD);  
  
        return dataSource;   
    }  
  
    @Bean  
    public PlatformTransactionManager transactionManager(DataSource dataSource) {  
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();  
        transactionManager.setDataSource(dataSource);  
        return transactionManager;  
    }}

이 코드를 실행시켜보면
datasourcetransactionmanager_log.png

이렇게 두개의 connection이 사용되는 것을 볼 수 있다.

2-2. JpaTransactionManager

이것은 일반적으로 사용하는 @Repository, @Transactional 애노테이션을 사용했을 때 사용되는 TransactionManager 객체이다. 이것은 획득한 Connection 객체를 로그로 보여주지 않기 때문에 확인하기가 어려워서 이 부분에서 하나의 커넥션으로 여러 트랜잭션이 일어나는것이 아닌가 의심이 들었다. 그치만 그렇게 생각하기에는 rollback point를 각각 잡아주면서 관리해야 할 것 같은데 굳이 그렇게 할까 싶기도 했다. 이것을 디버깅 하는 방법을 알게되었는데 다음과 같이 확인할 수 있다.

Hibernate-HikariCP-H2 구성으로 사용할 때에는 Connection 관련 부분들은 빈으로 등록되어있지 않기 때문에 AOP를 이용해서 로그를 걸 수 없고 IDE의 디버깅모드로 확인이 가능했다.

테스트 코드 구성 (Service, Repository, Entity)

Entity

package hello.jpaexample.account;  
  
import jakarta.persistence.*;  
import lombok.*;  
  
@Entity  
@Getter  
@NoArgsConstructor(access = AccessLevel.PROTECTED)  
public class Account {  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    long id;  
  
    @Column(unique = true)  
    String accountNumber;  
  
    @Column  
    int balance;  
  
    @Builder  
    public Account(String accountNumber, int balance) {  
        this.accountNumber = accountNumber;  
        this.balance = balance;  
    }  
    public void updateBalance(int newBalance) {  
        this.balance += newBalance;  
    }}

Repository

package hello.jpaexample.account;  
  
import org.springframework.data.jpa.repository.JpaRepository;  
import org.springframework.data.jpa.repository.Modifying;  
import org.springframework.data.jpa.repository.Query;  
import org.springframework.data.repository.query.Param;  
import org.springframework.stereotype.Repository;  
import org.springframework.transaction.annotation.Transactional;  
  
@Repository  
public interface AccountRepository extends JpaRepository<Account, Long> {  
    @Modifying  
    @Transactional    @Query("UPDATE Account a set a.balance = :balance where a.accountNumber = :accountNumber")  
    void updateBalance(@Param("accountNumber") String accountNumber, @Param("balance") int balance);  
}

Services

package hello.jpaexample.account;  
  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.stereotype.Service;  
import org.springframework.transaction.annotation.Transactional;  
  
@Service  
@Slf4j  
public class AccountOuterService {  
    @Autowired  
    private AccountRepository accountRepository;  
  
    @Autowired  
    private AccountInnerService accountInnerService;  
  
    @Transactional  
    public void createAccount(Account account) {  
        log.info("[OuterService] Create account {}", account);  
        accountRepository.save(account);  
    }  
    @Transactional  
    public void updateAccount(Account account) {  
        log.info("[OuterService] Update account {}", account);  
        accountInnerService.updateBalance(account.accountNumber, account.balance);  
    }  
    @Transactional  
    public void openingEvent() {  
        Account account = Account.builder().accountNumber("Account-1").balance(0).build();  
        log.info("[OuterService] Opening event {}", account);  
  
        createAccount(account);  
  
        account.updateBalance(10000);  
  
        updateAccount(account);  
    }  
    @Transactional  
    public void clear() {  
        accountRepository.deleteAll();  
    }}
package hello.jpaexample.account;  
  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.stereotype.Service;  
import org.springframework.transaction.annotation.Propagation;  
import org.springframework.transaction.annotation.Transactional;  
  
@Service  
@Slf4j  
public class AccountInnerService {  
    @Autowired  
    private AccountRepository accountRepository;  
  
    @Transactional(propagation = Propagation.REQUIRES_NEW)  
    public void updateBalance(String accountNumber, int newBalance) {  
        log.info("[InnerService] accountNumber={}, newBalance={}", accountNumber, newBalance);  
        accountRepository.updateBalance(accountNumber, newBalance);  
    }}

Test

package hello.jpaexample.account;  
  
import lombok.extern.slf4j.Slf4j;  
import org.junit.jupiter.api.BeforeEach;  
import org.junit.jupiter.api.Test;  
import org.springframework.beans.factory.annotation.Autowired;  
import org.springframework.boot.test.context.SpringBootTest;  
  
@SpringBootTest  
@Slf4j  
class AccountOuterServiceTest {  
    @Autowired  
    AccountOuterService accountOuterService;  
  
    @BeforeEach  
    void setUp() {  
        accountOuterService.clear();  
    }  
    @Test  
    void operate_two_transactions_with_two_connections() {  
        accountOuterService.openingEvent();  
    }  
}

테스트 디버깅

디버깅 포인트는 org.h2.jdbc 패키지의 JdbcConnection 클래스 내의 모든 prepareStatement() 메서드들에 건다.

h2_package_locatioin.png
jdbcConnection_class.png

그리고 디버깅을 실행시키면
insert_debugging.png insert_debugging2.png

insert문을 돌릴 때의 connection 객체와 update문을 돌릴때의 connection 객체가 다른 것을 알 수 있다. 이는 AccountInnerService 클래스의 updateBalance() 메서드를 새로운 트랜잭션으로 시작시켰기 때문이다. 즉, 새로운 트랜잭션이 시작되면 connection 객체를 새로 바인딩하여 사용한다. (updateBalance() 메서드에 Propagation 타입을 Default인 REQUIRED로 변경하면 같은 connection에서 쿼리가 실행된다.)


3. 새로운 커넥션을 사용할 때마다 스레드가 새로 할당될까요?

=> 하나의 DataSource당 하나의 스레드를 사용한다고 합니다.

spring docs-DataSourceTransactionManager에 이런글이 있습니다.

Binds a JDBC Connection from the specified DataSource to the current thread, potentially allowing for one thread-bound Connection per DataSource.

왜 스레드가 새로 생성되는것으로 알고있었을까요? 개인적인 생각입니다만, Controller단에서 외부 Request를 받을 때 서블릿 컨테이너가 각각의 요청에 대해 새로운 스레드를 할당하는데요, 이 요청 각각을 하나의 트랜잭션이라고 생각했던게 아닐까 싶습니다. HTTP Request로 인한 스레드 할당이 트랜잭션별 스레드 할당...으로 생각되었던 것이 아닐까 추측해봅니다.

스레드는 DataSource 객체와 관련이 있고, HTTP Request 요청으로 새로운 스레드가 할당되었다면 JDBC 커넥션은 그 스레드에서 실행이 된다.


4. 트랜잭션이 분리된 경우 롤백 예외가 전파될까요?

=> 예외처리를 안한다면 다른 트랜잭션에도 영향을 미칠것이고, 예외처리를 한다면 다른 트랜잭션에는 영향을 미치지 않을 것이다.

2-2.의 코드에 기능을 추가해서 예를 들어보도록 한다.

Service - AccountInnerService.class

//...

public class AccountInnerService {  

	//...
	
    @Transactional(propagation = Propagation.REQUIRES_NEW)  
    public void updateBalanceWithException(String accountNumber, int newBalance) {  
        log.info("[InnerService-Exception] accountNumber={}, newBalance={}", accountNumber, newBalance);  
        accountRepository.updateBalance(accountNumber, newBalance);  
        throw new UnexpectedRollbackException("Test");  
    }
    
	@Transactional  
	public void updateBalanceWithOneTransactionWithException(String accountNumber, int newBalance) {  
	    log.info("[InnerService-OneTransaction-Exception] accountNumber={}, newBalance={}", accountNumber, newBalance);  
	    accountRepository.updateBalance(accountNumber, newBalance);  
	    throw new UnexpectedRollbackException("Test");  
	}
}

Service - AccountOuterService.class

//...

public class AccountOuterService {  
    
    //...
    @Transactional  
	public void updateAccountWithException(Account account) {  
	    log.info("[OuterService-Exception] Update account {}", account);  
	    accountInnerService.updateBalanceWithException(account.accountNumber, account.balance);  
	}

	@Transactional  
	public void updateAccountWithOneTransactionWithException(Account account) {  
	    log.info("[OuterService-OneTransaction-Exception] Update account {}", account);  
	    accountInnerService.updateBalanceWithOneTransactionWithException(account.accountNumber, account.balance);  
	}

	@Transactional  
	public void openingEventWithException() {  
	    Account account = Account.builder().accountNumber("Account-1").balance(0).build();  
	    log.info("[OuterService-Exception] Opening event {}", account);  
	  
	    createAccount(account);  
	  
	    account.updateBalance(10000);  
	  
	    updateAccountWithException(account);  
	}  
	  
	@Transactional  
	public void openingEventWithExceptionAndHandle() {  
	    Account account = Account.builder().accountNumber("Account-1").balance(0).build();  
	    log.info("[OuterService-Exception-Handle] Opening event {}", account);  
	  
	    createAccount(account);  
	  
	    account.updateBalance(10000);  
	  
	    try {  
	        updateAccountWithException(account);  
	    } catch (Exception e) {  
	        log.info("[OuterService-Exception-Handle] error={}", e.getMessage());  
	    }
	}

	@Transactional  
	public void openingEventWithOneTransactionWithExceptionAndHandle() {  
	    Account account = Account.builder().accountNumber("Account-1").balance(0).build();  
	    log.info("[OuterService-OneTransaction-Exception-Handle] Opening event {}", account);  
	  
	    createAccount(account);  
	  
	    account.updateBalance(10000);  
	  
	    try {  
	        updateAccountWithOneTransactionWithException(account);  
	    } catch (Exception e) {  
	        log.info("[OuterService-OneTransaction-Exception-Handle] error={}", e.getMessage());  
	    }
    }
}

Test - AccountOuterServiceTest.class

//...

class AccountOuterServiceTest {  
    
    //...
    
    @Test  
    void operate_two_transactions_with_two_connections_exception() {  
        accountOuterService.openingEventWithException();  
    }  
    
    @Test  
    void operate_two_transactions_with_two_connections_exception_handle() {  
        accountOuterService.openingEventWithExceptionAndHandle();  
    }  

	@Test  
	void operate_one_transaction_with_exception() {  
	    accountOuterService.openingEventWithOneTransactionWithExceptionAndHandle();  
	}
}

자 여기서,

4-1. openingEventWithException()

여기서는 createAccount() 메서드에서 실행되었던 트랜잭션까지 롤백된다. 이것은 예외처리를 하지 않은데에 따른 예외의 전파때문이다.

4-2. openingEventWithExceptionAndHandle()

여기서는 updateAccount() 메서드의 트랜잭션만 롤백이 된다. 예외처리를 했고, 트랜잭션이 분리되어 있기 때문에 다른 트랜잭션의 rollbackOnly 플래그가 변경되지 않았기 때문이다. (*rollbackOnly 플래그는 트랜잭션을 마무리하기 위해서 commit 함수를 호출할 때 영향을 미치는 플래그로 이 플래그가 true로 되어있으면 이 트랜잭션은 commit이 불가능하고 rollback만 할 수 있다는 의미이다.)

4-3. openingEventWithOneTransactionWithExceptionAndHandle()

여기서는 createAccount() 메서드의 쿼리내용까지 롤백된다. 예외처리를 했음에도 불구하고 모든 쿼리의 롤백이 터지는 이유는 하나의 트랜잭션으로 묶여있기 때문에 이 트랜잭션 내의 rollbackonly 플래그가 true로 변경되었고, 하나의 쿼리에 대한 예외처리는 했지만, 다른 하나도 rollbackonly 플래그에 영향을 받기 때문에 모든 쿼리가 롤백이 되는 것이다.

4-4. 위의 세가지 경우를 그림으로 표현해보면 다음과 같습니다.

transaction_exception_propagation.png|400


5. 참고

5-1. Spring AOP @Transactional 관련